""" Implementation of the TileMap entity class. """

try:
    import numpy
except ImportError:
    warnings.warn(
    """
    
    Numpy is not installed on this system. You must install Numpy on this
    system before you will be able to use ScrollBack's tile map features.
    Go to http://numpy.scipy.org/ to download and install Numpy.
    ScrollBack will now run without support for tile maps.
    """
    )

import base64
import zlib

from sbak.misc import copy_some
from sbak.draw import image as draw_image
from sbak import resman

from base import Entity

class TileMap(Entity):
    """ An entity made of tiles.
        
        A TileMap is a wrapper around a numpy.ndarray object that holds
        the numeric indecis of Tile objects inside a TileList object.
        These indecis can be accessed via the indexing syntax of
        numpy.ndarray:
        
        >>> tmap = TileMap(...)
        >>> tmap[0,4]
        9
        >>> tmap[0:4,0:4]
        [[9,8,7,4],
         [6,5,4,1],
         [3,2,1,8],
         [0,1,2,2]]
         
        TileMaps can be drawn in whole or in part. They can partake in
        collision detection.
    """
    
    tag = 'tilemap'

    def __init__(   self,
                    world = None,
                    pos = (0,0),
                    layer = 0,
                    hidden = False,
                    priority = None,
                    id = None,
                    cellsize = (1,1),
                    tilelist = None, 
                    solid = False,
                    cells = None    ):
        """ Initializes a new TileMap object.
        
                        
            world:      The world that the tilemap will go in.
        
            pos:        Top-left corner of tilelist in a world. Pass an
                        (x,y) pair. Default is (0,0).
            
            layer:      Layer number. Used to determine drawing order.
                        Defaults 0.
                        
            hidden:     Whether or not to hide tilemap. If true the
                        tilemap will not be drawn by worlds.
                        Default false.
                        
            priority:   The priority of the tilemap when receiving events.
            
            id:         The ID string of the tilemap. The usual
                        entity ID rules apply.
            
            cellsize:  Size of each cell in the grid in pixels. Pass
                        an (x,y) pair. Default is (1,1).
                        
            tilelist:   TileList for tiles in grid.
            
            solid:      Whether or not tilemap participates in
                        collision checks. If true it does, if false it
                        does not. Default false.
                        
            cells:      Values of cells in the tile map. Pass a 2d
                        numpy.array or else a sequence-of-sequences,
                        where each subsequence of the outer sequence is
                        of the same length.
            
            TileMaps can also be created from data buffers. For this,
            see the TileMap.from_buffer() class method.
        """
        Entity.__init__(
            self,
            id = id,
            world = world,
            pos = pos,
            layer = layer,
            hidden = hidden,
            paused = paused,
            priority = priority,
        )
        
        self.solid = bool(solid)
        
        # Size of tiles
        self._csize = tuple(map(int, cellsize)) or (1,1) 
        
        # Get the tile list
        self.tilelist = tilelist
        
        # Cells of the grid.
        self._tdata = numpy.array(cells, int)

    @classmethod
    def from_buffer(cls,
                        id=None,
                        world=None,
                        pos=(0,0),
                        cell_size=(1,1),
                        tile_list=None, 
                        layer=0,
                        hidden=False,
                        solid=False,
                        paused=False,
                        grid_size=(1,1),
                        tdata='',
                        *vargs, **kwargs):
        """ TODO: Creates a new TileMap object from a buffer.
        
            id:         The ID string of the tilemap. The usual
                        entity ID rules apply.
                        
            world:      The world that the tilemap will go in.
        
            pos:        Top-left corner of tilelist in a world. Pass an
                        (x,y) pair. Default is (0,0).
                        
            cell_size:  Size of each cell in the grid in pixels. Pass
                        an (x,y) pair. Default is (1,1).
                        
            tilelist:   TileList for tiles in grid.
            
            layer:      Layer number. Used to determine drawing order.
                        Defaults 0.
                        
            hidden:     Whether or not to hide tilemap. If true the
                        tilemap will not be drawn by worlds.
                        Default false.
                        
            solid:      Whether or not tilemap participates in
                        collision checks. If true it does, if false it
                        does not. Default false.
            
            grid_size:  Number of columns and rows in grid. Pass a
                        (cols,rows) pair. Defaults is (1,1).
            
            tdata:      Tile data. Pass a buffer with exactly as many
                        values as can fill the grid. They must be
                        integers. If this argument is not passed the
                        grid will be filled with zeroes.
        """
        # Size of grid in tiles
        gsize = w,h = tuple(map(int, grid_size)) or (0,0) 
        
        # Create grid
        if tdata is None:
            cells = numpy.ndarray((w,h),int)
            cells.fill(0)
        elif len(tiles) == w*h:
            cells = numpy.ndarray((w,h),int,tdata)
        else:
            raise ValueError("Length of tdata does not match given grid size.")
        
        # Create the tilemap
        tmap = cls(id=id,
                   world=world,
                   pos=pos,
                   cell_size=cell_size,
                   tilelist=tilelist,
                   layer=layer,
                   hidden=hidden,
                   solid=solid,
                   cells=cells)
    
        return tmap

    @classmethod
    def from_json(cls, obj, resmgr=None):
        """ TODO: Creates a TileMap object from a JSON representation.
            
            Example usage:
        
            >>> tmap = TileMap.from_json(json_obj, my_res_mgr)
            TileMap(...)
        
            cls:    The TileMap class. Must be TileMap or a subclass of
                    TileMap.
            
            obj:    The JSON object from which the TileMap will be
                    constructed.
            
            resmgr: A resource manager object (Mgr)
            
            This will raise sbak.error.FormatError if a "tdata" section
            is present in the given JSON representation without the
            corresponding "*format" code, or if the format code is
            unrecognized.
        """
        if resmgr is None:
            resmgr = resman.get_mgr()
        
        # Create a copy of the given JSON object; it will be used to create the
        # arguments for the constructor.
        d = obj.copy()
        del obj
        
        # Ignore the "world" item if present to prevent unintentional
        # "teleporting" of TileMaps
        if "world" in d:
            del d["world"]
        
        # Ignore the "tag" item since no constructors accept
        if "tag" in d:
            del d["tag"]
        
        # Determine the format of tdata, then remove it from the dict.
        if "*format" in d:
            fmt = d['*format'].strip().lower()[0]
            del d['*format']
        else:
            fmt = None
        
        # If there is tdata, load and interpret it based on format code.
        if "tdata" in d:
            # "None" - No format code given, raise an error.
            if fmt is None:
                raise error.FormatError("Encountered TileMap representation "
                                        "with tdata, but no specified format.")
            
            # "b" - base64-encoded binary.
            if fmt == 'b': 
                d['tdata'] = base64.b64decode(d['tdata'])
            
            # "z" - zlib-compressed, base64-encoded binary
            elif fmt == 'z':
                d['tdata'] == zlib.decompress(base64.b64decode(d['tdata']))
            
            # "l" - JSON array/Python list.
            elif fmt == 'l':
                d['cells'] = d['tdata']
                del d['tdata']
            
            # Unknown format.
            else:
                raise error.FormatError(
                    "Encountered unknown format code '%s' while decoding "
                    "TileMap representation." % fmt
                )
        
        # If a tile list is described in the representation, get a TileList
        if "tilelist" in d:
            # If the tilelist is a string, assume it is a filename and load it
            # with the resource manager.
            if isinstance(d["tilelist"], basestring):
                d["tilelist"] = resmgr.get(d["tilelist"], "tilelist")
            
            # If its not a string, assume it's a JSON representation.
            else:
                d["tilelist"] = tilelist.from_json(d["tilelist"], resmgr)
        
        # Create the TileMap from the decoded JSON data using the appropriate
        # constructor.
        if "tdata" in d:
            tmap = cls.from_buffer(**d)
        else:
            tmap = cls(**d)
        
        return tmap
    
    def to_json(self, resmgr=None, usenone=False, format='b'):
        """ Create a JSON representation of the tilemap.
        
            ~~~ ARGUMENTS ~~~
        
            resmgr: A resource manager to use to help encode the tile
                    list. If None, the global resource manager is used.
            
            usenone:
                    If true, objects that cannot be represented as JSON
                    will be represented as None. Otherwise, an
                    exception is raised while parsing.
            
            format: Give a format specifier character. Only the first
                    non-whitespace character is used and is
                    case-insensitve, so you can type out the the whole
                    word and it will have the same effect. However,
                    an unknown first character will raise a ValueError.
            
            ~~~ TDATA FORMATS ~~~
            
            tdata for a tilemap cannot be easily represented in an
            human-readable way through plaintext, so one of
            several data formats must be used to store it. The default
            is 'b' for 'base 64-encoded binary' (or just 'binary'):
            
            'b' -   Base-64-encoded binary data. This will encode the
                    data as raw bytes but will be encoded using base64
                    so that it can be stored in a JSON file.
            
            'z' -   Base-64-encoded zlib-compressed binrary data. This
                    is the same as 'b', but data is compressed using
                    zlib compression before being encoded to binary,
                    which should result in dramaticly more efficient
                    use of space. Recommended for large maps,
                    especially those that contain the same tile
                    or sequence of tiles repeated many times.
            
            'l' -   Listed numbers. This is incredibly space
                    inefficient, but is the most easily human-readable
                    and human-editable format possible for plain text.
                    Suggested for using only with very, very small
                    maps.
                    
            ~~~ JSON FORMAT ~~~
            
            {
                "tag" : "tilemap",
                "pos" : [<float>, <float>],
                "layer" : <int>,
                "hidden" : <bool>,
                "solid" : <bool>,
                "paused" : <bool>,
                "cell_size" : [<int>, <int>],
                "grid_size" : [<int>, <int>],
                "tilelist" : <TileList JSON object | filename | null>,
                "*format" : <"b" | "z" | "l">,
                "tdata" : <tdata>
            }
            
            ~~~ JSON VALUES ~~~
            
            'tag'  :        The entity tag, "tilemap".
            
            "pos",
            "layer",
            "hidden",
            "solid",
            "paused" :      Usual entity attributes.
            
            "cell_size" :   Size of each cell in pixels.
            
            "grid_size" :   Size of the map in cells.
        
            "tilelist"  :   A JSON object describing a TileList,
                            a filename containing a tilelist, or
                            null depending on whether or not the
                            tile map's tile list is loaded from a
                            file or if there is one at all.
            
            "*format"   :   This attribute doesn't appear on the
                            tile map object itself, but is added to
                            the JSON object to determine how the tdata
                            should be loaded (thus the * at the
                            beginning indicates metadata).
            
            "tdata"     :   Tile data in the format specified by
                            the psuedo-attribute *format.
        """
        if resmgr is None:
            resmgr = resman.get_mgr()
        
        format = format.strip().lower()[0]
        if format == 'b':
            tdata = base64.b64encode(self._tdata.tostring())
        elif format == 'z':
            tdata = base64.b64encode(zlib.compress(self._tdata.tostring()))
        elif format == 'l':
            tdata = self._tdata.tolist()
        else:
            raise ValueError("Unknown format code '%s'."%format)
        
        d = {
            "cell_size" : map(int, self.cell_size),
            "grid_size" :map(int, self.grid_size),
            "tilelist " : resmgr.res_to_json(self.tilelist, usenone, True), 
            "*format" : format,
            "tdata" : tdata
        }
        
        
        # FIXME: This is a reference to a function that was removed a long time ago. This code can be made to use `dict.update` instead.
        
        # Use some of the values generated by the basic entity representer
        copy_some(
            d,
            super(TileMap, self).to_json(),
            ( "id", "pos", "layer", "hidden", "solid","paused" )
        )
        
        
        return d
    
    def __repr__(self):
        return "<TileMap %i x %i>" % self.gsize

    def __getitem__(self,k):
        return self._tdata.__getitem__(k)
    
    def __setitem__(self,k,v):
        return self._tdata.__setitem__(k,v)
    
    def get_tile(self,k):
        """ Returns the tile object at the given grid coordinates.
        
            This is like indexing the tilemap object except that the actual
            Tile object corresponding to the number at the grid position is
            returned rather than just the number.
        """
        return self._tlist[self[k]]
    
    def set_tilelist(self,tilelist):
        if isinstance(tilelist,str):
            self._tlist = resman.get(tilelist)
        else:
            self._tlist = (tilelist if hasattr(tilelist,'_is_tilelist')
                           else TileList(tilelist))
    tilelist = property(lambda self: self._tlist, set_tilelist, doc=
        """ The TileList being used to represent cells.
        
            This can be set either to an actual TileList object, an
            ordinary sequence of Tile objects (which will be converted
            into a TileList) or a string. If it is a string, the global
            resource manager is used to find and load the tile list if
            necessary.
        """)
    
    def draw(self,surf=None,offset=(0,0),area=None):
        """ Draws the tilemap.
            
            surf:   Pass a pygame Surface or None to draw to the screen.
            
            offset: Pass the position on the surface at which the tilelist
            should be drawn.
            
            area: Pass a pygame Rect object (or compatible) to draw that
            area of the tilemap or None to draw the whole tilemap.
            
            Note that `pos` and `area` are both in pixels.
            
            FIXME: This method needs to be faster. It has been optimized
            somewhat but I think it could still be improved.
        """
        # Localize some stuff
        maxMX,maxMY = self.grid_size
        cw,ch = self.cell_size
        tlist_get,tdata = self._tlist.__getitem__, self._tdata
        # Find the map area to draw
        if area is not None:
            area = Rect(area).move([-v for v in self.pos])
            px,py,pw,ph = area  # Pixel area
            # Map area
            (mx,my),(mw,mh) = (int(px/cw), int(py/ch)), \
                            (int(ceil(pw*1.0/cw))+1, int(ceil(ph*1.0/ch))+1)
            # Base drawing offset
            bdx,bdy = sum_multi(offset,[-v for v in area[0:2]])
        else:
            # Map area
            (mx,my),(mw,mh) = (0,0),self.grid_size
            # Base drawing offset
            bdx,bdy = sum_multi(self.pos,offset)
        # Draw each tile in the area
        for o in xrange(my,mh+my):
            for i in xrange(mx,mw+mx):
                if i>=0 and i<maxMX and o>=0 and o<maxMY:
                    # Use direct indexing for a slight speed boost
                    tlist_get( tdata[i,o] ).draw(surf, (bdx+(i*cw), bdy+(o*ch)) )
    
    def set_grid_size(self, size):
        if tuple(self._tdata.shape) == tuple(size[:2]):
            return
        self._tdata = numpy.resize(self._tdata,size[:2])
    grid_size = property(lambda self: self._tdata.shape, set_grid_size, doc=
        """ Size of the tile map's grid measured in cells.
        
            This will always be a tuple of two integers, width and
            height.
            
            WARNING: The tile map's grid can be resized using this
            property, but decreasing either width or height will result
            in the loss of data. Increasing either dimension will
            result in new cells with each having a value of 0.
        """)
    
    def set_cell_size(self,v):
        self._csize = tuple(map(int,v[:2]))
    cell_size = property(lambda self: self._csize, set_cell_size, doc=
        """ The size of each cell in pixels.
        
            This is the size that each cell of the tile map's grid
            occupies in pixels. It is unaffected by the tile set in
            use.
            
            WARNING: Changing this will dramatically alter the total
            size of the map measured in pixels. However, it will not
            change or alter the grid's data or structure.
        """)
    
    # ~~ Collision Detection ~~
    
    def find_collider_tiles(self, rect):
        """ Returns a list of all tile objects touching a region.
        
            The rectangle is assumed to be in pixels, and the position of the
            tilemap in the world (the offset) is taken into account.
            Returns an empty list if none touch.
        """
        rect = Rect(rect)
        # Quit now if the given rect does not collide with the tilemap.
        if not rect.colliderect(self.rect):
            return []
        # Localize/ungroup variables
        cw,ch = self._csize
        w,h = self._gsize
        tlist = self._tlist
        tdata = self._tdata
        rx,ry,rw,rh = rect.move([-v for v in self._pos])
        gl,gt,gr,gb = [int(v) for v in
                       (rx/cw, ry/ch, ceil(1.0*(rx+rw)/cw),
                        ceil(1.0*(ry+rh)/ch))]
        # Clip the range
        if gl < 0: gl=0
        if gt < 0: gt=0
        if gr > w: gr = w
        if gb > h: gb = h
        # Get all tiles in the range
        L = []
        for y in xrange(gt,gb):
            for x in xrange(gl,gr):
                L.append(tlist[tdata[x,y]])
        return L
    
    def collide_num(self,rect,num):
        """ Check for collisions between rectangle and tiles of a given number.
            Returns True if a collision is detected and False if not.
        """
        for t in self.find_collider_tiles(rect):
            if t.idn == num:
                return True
        return False
    
    def collide_group(self, rect, group, offset=(0,0)):
        """ Check for collisions between rectangle and tiles of a given group.
            Returns True if a collision is detected and False if not.
        """
        for t in self.find_collider_tiles(rect):
            if t in group:
                return True
        return False
    
    def collide_groups(self, rect, groups, offset=(0,0)):
        """ Check for collisions between rectangle and tiles of a given group.
            Returns True if a collision is detected and False if not.
        """
        for t in self.find_collider_tiles(rect):
            for grp in groups:
                if t in grp:
                    return True
        return False
    
    # ~~ Size/shape properties ~~

    size = property(lambda self: [c*p for c,p in zip(self._csize)], doc=
        """ Size of map in pixels as int. Read only. """)

    width = w = property(
        lambda self: self._tdata.shape[0]*self._csize[0], doc =
        """ Width of map measured in pixels as int. Read only. """)
        
    height = h = property(
        lambda self: self._tdata.shape[1]*self._csize[1],doc = 
        """ Height of map measured in pixels as int. Read only. """)

    bbox = property(lambda self:
        Rect((0,0), [self._tdata.shape[n]*self._csize[n] for n in xrange(2)]),
        doc = 
        """ Return occupied region measured in pixels as pygame.Rect.
            The position component will always be 0,0.
            Read-only.
        """)

    region = property(lambda self:
        Rect(self._pos,
             [self._tdata.shape[n]*self._csize[n] for n in xrange(2)]),
        doc = 
        """ Occupied region measured in pixels as pygame.Rect.
        
            Note that due to Pygame's limitation of Rect objects
            being made of ints, the values will be floored. If
            the precise position of the object is needed, you must use
            to use x, y, top, left, etc.
            
            Read only. To change the size of the map, you must either
            change the size of its grid, the size of its cells, or
            both.
        """)
    
    @property
    def left(self):
        return self.pos.x
    
    @property
    def top(self):
        return self.pos.y
    
    def set_right(self,val):
        self._pos[0] = v-self._tdata.shape[0]*self._csize[0]
    right = property(lambda self:
        self._pos[0]+self._tdata.shape[0]*self._csize[0],
        set_right, doc =
        """ Right edge in pixels as float. """)
    
    def set_bottom(self, val):
        self._pos[1] = v-self._tdata.shape[0]*self._csize[1]
    bottom = property(lambda self:
        self._pos[1]+self._tdata.shape[1]*self._csize[1],
        set_bottom, doc =
        """ Bottom edge in pixels as float. """)
    
    topleft = property(doc=
        """ TODO: Top-left-most point occupied as pair of floats. """)
    
    topright = property(doc=
        """ TODO: Top-right-most point occupied as pair of floats. """)
    
    bottomleft = property(doc=
        """ TODO: Bottom-left-most point occupied as pair of floats. """)
    
    bottomright = property(doc=
        """ TODO: Bottom-right-most point occupied as pair of floats. """)
